<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<style>
html, body {
	margin: 0;
	padding: 0;
	height: 100%;
	font-family: 'Arial', sans-serif;
}
#video {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	background: rgb(30, 30, 30);
}
#controls {
	display: none;
	flex-shrink: 0;
	align-items: center;
	justify-content: center;
	padding: 10px;
	flex-direction: column;
	min-height: 100%;
	width: 100%;
	box-sizing: border-box;
	background: rgb(30, 30, 30);
	color: white;
}
.item {
	display: grid;
	grid-auto-flow: column;
	grid-template-columns: auto 220px;
	align-items: center;
	gap: 20px;
	max-width: 500px;
	margin: 10px 0;
}
select, input[type="text"] {
	appearance: none;
	background: inherit;
	color: inherit;
	border: 1px solid rgb(200, 200, 200);
	border-radius: 3px;
	height: 40px;
}
#message {
	position: absolute;
	left: 0;
	top: 0;
	width: 100%;
	height: 100%;
	display: flex;
	align-items: center;
	text-align: center;
	justify-content: center;
	font-size: 16px;
	font-weight: bold;
	color: white;
	pointer-events: none;
	padding: 20px;
	box-sizing: border-box;
	text-shadow: 0 0 5px black;
}
#publish-button {
	margin-top: 10px;
	appearance: none;
	background: rgb(200, 200, 200);
	color: black;
	border-radius: 3px;
	height: 50px;
	padding: 0 20px;
	border: none;
}
</style>
</head>
<body>

<video id="video" muted autoplay playsinline></video>

<div id="controls">
	<div id="items">
		<div class="item">
			<label for="video-device">video device</label>
			<select id="video-device">
				<option value="none">none</option>
			</select>
		</div>

		<div class="item">
			<label for="video-codec">video codec</label>
			<select id="video-codec">
			</select>
		</div>

		<div class="item">
			<label for="video-bitrate">video bitrate (kbps)</label>
			<input id="video-bitrate" type="text" value="10000" />
		</div>

		<div class="item">
			<label for="video-framerate">video framerate</label>
			<input id="video-framerate" type="text" value="30" />
		</div>

		<div class="item">
			<label for="video-width">video width</label>
			<input id="video-width" type="text" value="1920" />
		</div>

		<div class="item">
			<label for="video-height">video height</label>
			<input id="video-height" type="text" value="1080" />
		</div>

		<div class="item">
			<label for="audio-device">audio device</label>
			<select id="audio-device">
				<option value="none">none</option>
			</select>
		</div>

		<div class="item">
			<label for="audio-codec">audio codec</label>
			<select id="audio-codec">
			</select>
		</div>

		<div class="item">
			<label for="audio-bitrate">audio bitrate (kbps)</label>
			<input id="audio-bitrate" type="text" value="32" />
		</div>

		<div class="item">
			<label for="audio-voice">optimize for voice</label>
			<div>
				<input id="audio-voice" type="checkbox" checked>
			</div>
		</div>
	</div>

	<div id="submit-line">
		<button id="publish-button">publish</button>
	</div>
</div>

<div id="message"></div>

<script>

const retryPause = 2000;

const video = document.getElementById('video');
const controls = document.getElementById('controls');
const message = document.getElementById('message');
const publishButton = document.getElementById('publish-button');

const videoForm = {
	device: document.getElementById('video-device'),
	codec: document.getElementById('video-codec'),
	bitrate: document.getElementById('video-bitrate'),
	framerate: document.getElementById('video-framerate'),
	width: document.getElementById('video-width'),
	height: document.getElementById('video-height')
};

const audioForm = {
	device: document.getElementById('audio-device'),
	codec: document.getElementById('audio-codec'),
	bitrate: document.getElementById('audio-bitrate'),
	voice: document.getElementById('audio-voice'),
};

let pc = null;
let stream = null;
let restartTimeout = null;
let sessionUrl = '';
let offerData = '';
let queuedCandidates = [];

const setMessage = (str) => {
	message.innerText = str;
};

const onError = (err, retry) => {
	if (!retry) {
		setMessage(err);
	} else {
		if (restartTimeout === null) {
			setMessage(err + ', retrying in some seconds');

			if (pc !== null) {
				pc.close();
				pc = null;
			}

			restartTimeout = window.setTimeout(() => {
				restartTimeout = null;
				startTransmit();
			}, retryPause);

			if (sessionUrl) {
				fetch(sessionUrl, {
					method: 'DELETE',
				});
			}
			sessionUrl = '';

			queuedCandidates = [];
		}
	}
};

const unquoteCredential = (v) => (
	JSON.parse(`"${v}"`)
);

const linkToIceServers = (links) => (
	(links !== null) ? links.split(', ').map((link) => {
		const m = link.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);
		const ret = {
			urls: [m[1]],
		};

		if (m[3] !== undefined) {
			ret.username = unquoteCredential(m[3]);
			ret.credential = unquoteCredential(m[4]);
			ret.credentialType = 'password';
		}

		return ret;
	}) : []
);

const parseOffer = (offer) => {
	const ret = {
		iceUfrag: '',
		icePwd: '',
		medias: [],
	};

	for (const line of offer.split('\r\n')) {
		if (line.startsWith('m=')) {
			ret.medias.push(line.slice('m='.length));
		} else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
			ret.iceUfrag = line.slice('a=ice-ufrag:'.length);
		} else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) {
			ret.icePwd = line.slice('a=ice-pwd:'.length);
		}
	}

	return ret;
};

const generateSdpFragment = (od, candidates) => {
	const candidatesByMedia = {};
	for (const candidate of candidates) {
		const mid = candidate.sdpMLineIndex;
		if (candidatesByMedia[mid] === undefined) {
			candidatesByMedia[mid] = [];
		}
		candidatesByMedia[mid].push(candidate);
	}

	let frag = 'a=ice-ufrag:' + od.iceUfrag + '\r\n'
		+ 'a=ice-pwd:' + od.icePwd + '\r\n';

	let mid = 0;

	for (const media of od.medias) {
		if (candidatesByMedia[mid] !== undefined) {
			frag += 'm=' + media + '\r\n'
				+ 'a=mid:' + mid + '\r\n';

			for (const candidate of candidatesByMedia[mid]) {
				frag += 'a=' + candidate.candidate + '\r\n';
			}
		}
		mid++;
	}

	return frag;
};

const setCodec = (section, codec) => {
	const lines = section.split('\r\n');
	const lines2 = [];
	const payloadFormats = [];

	for (const line of lines) {
		if (!line.startsWith('a=rtpmap:')) {
			lines2.push(line);
		} else {
			if (line.toLowerCase().includes(codec)) {
				payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]);
				lines2.push(line);
			}
		}
	}

	const lines3 = [];

	for (const line of lines2) {
		if (line.startsWith('a=fmtp:')) {
			if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) {
				lines3.push(line);
			}
		} else if (line.startsWith('a=rtcp-fb:')) {
			if (payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0])) {
				lines3.push(line);
			}
		} else {
			lines3.push(line);
		}
	}

	return lines3.join('\r\n');
};

const setVideoBitrate = (section, bitrate) => {
	let lines = section.split('\r\n');

	for (let i = 0; i < lines.length; i++) {
		if (lines[i].startsWith('c=')) {
			lines = [...lines.slice(0, i+1), 'b=TIAS:' + (parseInt(bitrate) * 1024).toString(), ...lines.slice(i+1)];
			break
		}
	}

	return lines.join('\r\n');
};

const setAudioBitrate = (section, bitrate, voice) => {
	let opusPayloadFormat = '';
	let lines = section.split('\r\n');

	for (let i = 0; i < lines.length; i++) {
		if (lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/')) {
			opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0];
			break;
		}
	}

	if (opusPayloadFormat === '') {
		return section;
	}

	for (let i = 0; i < lines.length; i++) {
		if (lines[i].startsWith('a=fmtp:' + opusPayloadFormat + ' ')) {
			if (voice) {
				lines[i] = 'a=fmtp:' + opusPayloadFormat + ' minptime=10;useinbandfec=1;maxaveragebitrate='
					+ (parseInt(bitrate) * 1024).toString();
			} else {
				lines[i] = 'a=fmtp:' + opusPayloadFormat + ' maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate'
					+ (parseInt(bitrate) * 1024).toString();
			}
		}
	}

	return lines.join('\r\n');
};

const editAnswer = (sdp, videoCodec, audioCodec, videoBitrate, audioBitrate, audioVoice) => {
	const sections = sdp.split('m=');

	for (let i = 0; i < sections.length; i++) {
		const section = sections[i];
		if (section.startsWith('video')) {
			sections[i] = setVideoBitrate(setCodec(section, videoCodec), videoBitrate);
		} else if (section.startsWith('audio')) {
			sections[i] = setAudioBitrate(setCodec(section, audioCodec), audioBitrate, audioVoice);
		}
	}

	return sections.join('m=');
};

const sendLocalCandidates = (candidates) => {
	fetch(sessionUrl + window.location.search, {
		method: 'PATCH',
		headers: {
			'Content-Type': 'application/trickle-ice-sdpfrag',
			'If-Match': '*',
		},
		body: generateSdpFragment(offerData, candidates),
	})
		.then((res) => {
			if (res.status !== 204) {
				throw new Error('bad status code');
			}
		})
		.catch((err) => {
			onError(err.toString(), true);
		});
};

const onLocalCandidate = (evt) => {
	if (restartTimeout !== null) {
		return;
	}

	if (evt.candidate !== null) {
		if (sessionUrl === '') {
			queuedCandidates.push(evt.candidate);
		} else {
			sendLocalCandidates([evt.candidate])
		}
	}
};

const onRemoteAnswer = (sdp) => {
	if (restartTimeout !== null) {
		return;
	}

	sdp = editAnswer(
		sdp,
		videoForm.codec.value,
		audioForm.codec.value,
		videoForm.bitrate.value,
		audioForm.bitrate.value,
		audioForm.voice.checked,
	);

	pc.setRemoteDescription(new RTCSessionDescription({
		type: 'answer',
		sdp,
	}));

	if (queuedCandidates.length !== 0) {
		sendLocalCandidates(queuedCandidates);
		queuedCandidates = [];
	}
};

const sendOffer = (offer) => {
	fetch(new URL('whip', window.location.href) + window.location.search, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/sdp',
		},
		body: offer.sdp,
	})
		.then((res) => {
			if (res.status !== 201) {
				throw new Error('bad status code');
			}
			sessionUrl = new URL(res.headers.get('location'), window.location.href).toString();
			return res.text();
		})
		.then((sdp) => onRemoteAnswer(sdp))
		.catch((err) => {
			onError(err.toString(), true);
		});
};

const createOffer = () => {
	pc.createOffer()
		.then((offer) => {
			offerData = parseOffer(offer.sdp);
			pc.setLocalDescription(offer);
			sendOffer(offer);
		});
};

const onConnectionState = () => {
	if (restartTimeout !== null) {
		return;
	}

	if (pc.iceConnectionState === 'disconnected') {
		onError('peer connection disconnected', true);
	} else if (pc.iceConnectionState === 'connected') {
		setMessage('');
	}
};

const requestICEServers = () => {
	fetch(new URL('whip', window.location.href) + window.location.search, {
		method: 'OPTIONS',
	})
		.then((res) => {
			pc = new RTCPeerConnection({
				iceServers: linkToIceServers(res.headers.get('Link')),
				// https://webrtc.org/getting-started/unified-plan-transition-guide
				sdpSemantics: 'unified-plan',
			});

			pc.onicecandidate = (evt) => onLocalCandidate(evt);
			pc.oniceconnectionstatechange = () => onConnectionState();

			stream.getTracks().forEach((track) => {
				pc.addTrack(track, stream);
			});

			createOffer();
		})
		.catch((err) => {
			onError(err.toString(), true);
		});
};

const startTransmit = () => {
	requestICEServers();
};

const onPublish = () => {
	controls.style.display = 'none';
	video.style.display = 'block';
	setMessage('connecting');

	const videoId = videoForm.device.value;
	const audioId = audioForm.device.value;

	if (videoId !== 'screen') {
		let videoOpts = false;

		if (videoId !== 'none') {
			videoOpts = {
				deviceId: videoId,
			};
		}

		let audioOpts = false;

		if (audioId !== 'none') {
			audioOpts = {
				deviceId: audioId,
			};

			const voice = audioForm.voice.checked;
			if (!voice) {
				audioOpts.autoGainControl = false;
				audioOpts.echoCancellation = false;
				audioOpts.noiseSuppression = false;
			}
		}

		navigator.mediaDevices.getUserMedia({
			video: videoOpts,
			audio: audioOpts,
		})
			.then((str) => {
				stream = str;
				video.srcObject = stream;
				startTransmit();
			})
			.catch((err) => {
				onError(err.toString(), false);
			});
	} else {
		navigator.mediaDevices.getDisplayMedia({
			video: {
				width: { ideal: videoForm.width.value },
				height: { ideal: videoForm.height.value },
				frameRate: { ideal: videoForm.framerate.value },
				cursor: 'always',
			},
			audio: true,
		})
			.then((str) => {
				stream = str;
				video.srcObject = stream;
				startTransmit();
			})
			.catch((err) => {
				onError(err.toString(), false);
			});
	}
};

const populateDevices = () => {
	return navigator.mediaDevices.enumerateDevices()
		.then((devices) => {
			for (const device of devices) {
				switch (device.kind) {
				case 'videoinput':
					{
						const opt = document.createElement('option');
						opt.value = device.deviceId;
						opt.text = device.label;
						videoForm.device.appendChild(opt);
					}
					break;

				case 'audioinput':
					{
						const opt = document.createElement('option');
						opt.value = device.deviceId;
						opt.text = device.label;
						audioForm.device.appendChild(opt);
					}
					break;
				}
			}

			if (navigator.mediaDevices.getDisplayMedia !== undefined) {
				const opt = document.createElement('option');
				opt.value = 'screen';
				opt.text = 'screen';
				videoForm.device.appendChild(opt);
			}

			if (videoForm.device.children.length !== 0) {
				videoForm.device.value = videoForm.device.children[1].value;
			}

			if (audioForm.device.children.length !== 0) {
				audioForm.device.value = audioForm.device.children[1].value;
			}
		});
};

const populateCodecs = () => {
	const tempPC = new RTCPeerConnection({});
	tempPC.addTransceiver('video', { direction: 'sendonly' });
	tempPC.addTransceiver('audio', { direction: 'sendonly' });

	return tempPC.createOffer()
		.then((desc) => {
			const sdp = desc.sdp.toLowerCase();

			for (const codec of ['av1/90000', 'vp9/90000', 'vp8/90000', 'h264/90000']) {
				if (sdp.includes(codec)) {
					const opt = document.createElement('option');
					opt.value = codec;
					opt.text = codec.split('/')[0].toUpperCase();
					videoForm.codec.appendChild(opt);
				}
			}

			for (const codec of ['opus/48000', 'g722/8000', 'pcmu/8000', 'pcma/8000']) {
				if (sdp.includes(codec)) {
					const opt = document.createElement('option');
					opt.value = codec;
					opt.text = codec.split('/')[0].toUpperCase();
					audioForm.codec.appendChild(opt);
				}
			}

			tempPC.close();
		});
};

const populateOptions = () => {
	setMessage('loading devices');

	navigator.mediaDevices.getUserMedia({ video: true, audio: true })
		.then((tempStream) => {
			return Promise.all([
				populateDevices(),
				populateCodecs(),
			])
				.then(() => {
					// free the webcam to prevent 'NotReadableError' on Android
					tempStream.getTracks()
						.forEach((track) => track.stop());

					setMessage('');
					video.style.display = 'none';
					controls.style.display = 'flex';
				});
		})
		.catch((err) => {
			onError(err.toString(), false);
		});
};

const updateQueryOnControls = () => {
	const url = new URL(window.location.href);
	const inputs = [...Object.values(videoForm), ...Object.values(audioForm)]

	for (const input of inputs) {
		if (input instanceof HTMLInputElement && input.type === 'text') {
			input.addEventListener('input', () => {
				url.searchParams.set(input.id, input.value);
				window.history.replaceState(null, null, url);
			})
		}

		if (input instanceof HTMLInputElement && input.type === 'checkbox') {
			input.addEventListener('input', () => {
				url.searchParams.set(input.id, input.checked);
				window.history.replaceState(null, null, url);
			})
		}

		if (input instanceof HTMLSelectElement) {
			input.addEventListener('input', () => {
				url.searchParams.set(input.id, input.value);
				window.history.replaceState(null, null, url);
			})
		}
	}
};

const loadControlsFromQuery = () => {
	const params = new URLSearchParams(window.location.search);
	const inputs = [...Object.values(videoForm), ...Object.values(audioForm)]

	for (const input of inputs) {
		const value = params.get(input.id);
		if (value) {
			if (input instanceof HTMLInputElement && input.type === 'text') {
				input.value = value;
			} else if (input instanceof HTMLInputElement && input.type === 'checkbox') {
				input.checked = value === 'true';
			} else if (input instanceof HTMLSelectElement) {
				input.value = value
			}
		}
	}
};

const init = () => {
	if (navigator.mediaDevices === undefined) {
		onError(`can't access webcams or microphones. Make sure that WebRTC encryption is enabled.`, false);
		return;
	}

	loadControlsFromQuery();
	updateQueryOnControls();
	publishButton.addEventListener('click', onPublish);
	populateOptions();
};

window.addEventListener('DOMContentLoaded', init);

</script>

</body>
</html>
